今日目標,將房間頁面的資訊透過 WebSocket 串接並即時顯示。
當某個房間內的相關資訊改變的時候,比如:房主換人、有人進出造成人數改變、有人準備或取消準備,我們預期將該房間的最新資訊傳給所有在那個房間的人,但我們又不希望隔壁房間的也收到這個房間的消息,如果用前面的方法,先訂閱某個代理再將訊息傳給代理會造成所有人都接收到。
也許讀者會想那我就再開一個代理專門給那個房間的人訂閱就好啊?但這是不太可能的,還記得在 Controller 的配置嗎?我們必須提前知道使用者傳訊息給哪個代理,並對其做處理,所以不太可能在執行的時候做出動態的決策。
因此,我們這時候就要使用一個觀念,群播(multicast),但其實在 WebSocket 中,群播的本質是單播(unicast),也就是對單一目標的訊息傳輸,那只要我們對多個使用者個別使用單播發送消息,即可做到群播的功能。
在實作之前,我們先補足觀念~~
對 URL 的細部作解釋,其實這邊對於單純想實作的讀者可能幫助不大,因為這邊的細節 Spring Boot 都有相關模組幫忙完成了,讀者根據自己需求決定要不要跳過囉~~
/user/queue/room-info
UserDestinationMessageHandler
自動轉換成單一使用者的目標位置(可以想成就是一個獨一無二的地址,對應到特定的使用者),比如某個使用者的名稱為 mark,那在這邊轉換之後會生成 /queue/room-info-mark
/user/{username}/queue/room-info
的 URL 作響應這邊僅簡述怎麼用,以及所需要的參數等。
/queue
simpMessagingTemplate.convertAndSendToUser()
,而它的參數分別為「使用者」、「目的 URL」、「訊息」/user/queue/room-info
再來,我們回到專案開始實作功能~~
configureMessageBroker()
內的 config.enableSimpleBroker
加入另一個前綴 /queue
,修改後的內容(片段)為:
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue"); // 多加一個前綴 "/queue"
}
sendMessageToRoom
,加入的片段程式碼為:
public void sendMessageToRoom(String roomId, String destination, Object message) {
// 取得指定房間
Room room = roomList.getRoomById(roomId);
// 取得房間內的所有成員
ArrayList<String> roomMembers = room.getAllMembers();
// 發送資訊給所有房間內的成員
for (String member : roomMembers) {
simpMessagingTemplate.convertAndSendToUser(member, destination, message);
}
}
sendRoomInfo()
到 RoomService,加入的片段程式碼為:
public void sendRoomInfo(String roomId) {
Room room = roomList.getRoomById(roomId);
ArrayList<String> roomMembers = room.getAllMembers();
// 把使用者是否準備的狀態取出
Map<String, Boolean> userReadyStatus = new HashMap<>();
for (String member : roomMembers) {
userReadyStatus.put(member, this.userStatus.isUserReady(member));
}
Map<String, Object> response = new HashMap<>();
response.put("userStatus", userReadyStatus);
// 取得房間資訊
response.put("roomInfo", room.getInfo());
// 發送給該房間的所有人
sendMessageToRoom(roomId, "/queue/room-info", response);
}
room.js
,完整內容為:
var websocket = new WebSocket();
var myUsername = $("#my-username").val();
var roomId;
var owner;
websocket.connect('/connect', () => {
subscribeRoomInfo();
websocket.send(`/room-info`, {});
});
function subscribeRoomInfo() {
websocket.subscribe("/user/queue/room-info", (response) => {
response = JSON.parse(response.body);
let roomInfo = response.roomInfo;
owner = roomInfo.owner;
roomId = roomInfo.roomId;
$("#room-id").text(roomId);
// 取得所有成員,如果自己並不在這之中,就跳轉到 rooms
let roomMembers = [owner, ...roomInfo.guests]
if (!roomMembers.includes(myUsername)) {
window.location.href = `/rooms/`;
}
// 顯示房間內的成員
let userStatus = response.userStatus;
$("#room .card").each((index, element) => {
let name = roomMembers[index];
if (name === myUsername) {
$(element).addClass("shadow rounded");
}
// 如果自己是房主,那其他成員右上方會出現 X 的按鈕,用於把成員踢出去
let closeButton = "";
if (owner === myUsername && name !== myUsername) {
closeButton = `
<button type="button" class="close close-button" onclick="quitRoom('${name}')">
<i class="bi bi-x-circle-fill"></i>
</button>
`;
}
// 如果已經準備,就顯示「準備」,房主就改成顯示「房主」
let readyText = "";
if (userStatus[name]) {
readyText = "準備";
}
if (name === owner) {
readyText = "房主";
}
// 顯示玩家名稱、玩家頭貼(目前大家都一樣,還沒特別處理~~)
$(element).prop("id", `user-${name}`);
$(element).empty();
if (index < roomMembers.length) {
$(element).html(`
${closeButton}
<img src="https://picsum.photos/200/200" class="card-img-top" alt="...">
<div class="card-body">
<h5 class="card-title">${name}</h5>
<h4 class="card-body user-ready">${readyText}</h4>
</div>
`);
}
else {
$(element).html(`
<div class="card-body">
<h5 class="card-title"></h5>
</div>
`);
}
})
// 如果自己是房主,改變準備按鈕的文字為「開始」
$("#ready").text(owner === myUsername ? '開始' : '準備');
})
}
@MessageMapping("/room-info")
public void getRoomInfo(Principal principal) {
String username = principal.getName();
String roomId = this.userStatus.getUserRoomId(username);
this.roomService.sendRoomInfo(roomId);
}
本來預計今天還要實作「準備」的功能,但寫到這發現內容還挺多的,所以就明天再寫吧~~